Sidequest

Score Manager

Now that we're clearing pipes, we need something to keep track of the score and display it.

Breakdown

We'll be using bitmap images of numbers to draw the score. You could use a font and the canvas fillText() method, but there are issues in Chrome with pixelated font rendering, so we'll opt for this route. It's a pretty common technique, and it can be more performant than using a font.

The sprite sheet and sprite map are already setup for this. You'll notice the sprite-map.ts file has a text sub-property. This is storing the sprite information for all the digits 0 through 9. No different than our other entities.

The new challenge here will be taking a score such as 10 and drawing it to the screen. We'll need to break down the score to it's individual digits, and draw each digit to the screen. We'll also want some padding between the digits. To do this we'll build up an in memory canvas that we draw the digits to, than finally draw that canvas onto our main canvas.

This may seem a little odd. We're going to create a canvas but not add it to the DOM. This is a very useful technique, and is commonly refer to as a buffer canvas. Think of it like constructing an image dynamically in memory, than finally drawing it. In that sense it's no different than what we've been doing all along, only we don't load this image from disk.

Score Manager

Let's create the score manager:

1
import { SpriteData } from "#/components/sprite-data";
2
import { Vector2d } from "#/components/vector2d";
3
import { Config } from "#/config";
4
import { SpriteMap } from "#/sprite-map";
5
6
type ScoreManagerOptions = {
7
config: Config;
8
spriteSheet: HTMLImageElement;
9
spriteMap: SpriteMap;
10
};
11
12
export class ScoreManager {
13
spriteMap: SpriteMap;
14
spriteSheet: HTMLImageElement;
15
public score = 0;
16
private previousScore = 0;
17
private characterPadding = 1;
18
private scorePosition: Vector2d;
19
20
private textCanvas = document.createElement("canvas");
21
private textContext: CanvasRenderingContext2D;
22
23
constructor(options: ScoreManagerOptions) {
24
this.spriteMap = options.spriteMap;
25
this.spriteSheet = options.spriteSheet;
26
this.scorePosition = new Vector2d(options.config.gameWidth / 2, 24);
27
28
const textContext = this.textCanvas.getContext("2d");
29
if (textContext == null) {
30
throw new Error("Failed to create text context");
31
}
32
33
this.textContext = textContext;
34
35
this.convertNumberToImage(this.score);
36
}
37
38
public reset() {
39
// Just reset `score`, update will take care of checking the previous score
40
// and redrawing the score at 0.
41
this.score = 0;
42
}
43
44
public draw(context: CanvasRenderingContext2D) {
45
if (this.previousScore !== this.score) {
46
this.convertNumberToImage(this.score);
47
48
this.previousScore = this.score;
49
}
50
51
context.drawImage(
52
this.textCanvas,
53
Math.floor(this.scorePosition.x - this.textCanvas.width / 2),
54
this.scorePosition.y
55
);
56
}
57
58
convertNumberToImage(score: number): void {
59
const numberString = score.toString();
60
61
const characters: Array<{
62
character: string;
63
frame: SpriteData;
64
width: number;
65
}> = [];
66
67
for (const character of numberString) {
68
switch (character) {
69
case "1":
70
characters.push({
71
character,
72
frame: this.spriteMap.text.one,
73
width: this.spriteMap.text.one.width,
74
});
75
break;
76
case "2":
77
characters.push({
78
character,
79
frame: this.spriteMap.text.two,
80
width: this.spriteMap.text.two.width,
81
});
82
break;
83
case "3":
84
characters.push({
85
character,
86
frame: this.spriteMap.text.three,
87
width: this.spriteMap.text.three.width,
88
});
89
break;
90
case "4":
91
characters.push({
92
character,
93
frame: this.spriteMap.text.four,
94
width: this.spriteMap.text.four.width,
95
});
96
break;
97
case "5":
98
characters.push({
99
character,
100
frame: this.spriteMap.text.five,
101
width: this.spriteMap.text.five.width,
102
});
103
break;
104
case "6":
105
characters.push({
106
character,
107
frame: this.spriteMap.text.six,
108
width: this.spriteMap.text.six.width,
109
});
110
break;
111
case "7":
112
characters.push({
113
character,
114
frame: this.spriteMap.text.seven,
115
width: this.spriteMap.text.seven.width,
116
});
117
break;
118
case "8":
119
characters.push({
120
character,
121
frame: this.spriteMap.text.eight,
122
width: this.spriteMap.text.eight.width,
123
});
124
break;
125
case "9":
126
characters.push({
127
character,
128
frame: this.spriteMap.text.nine,
129
width: this.spriteMap.text.nine.width,
130
});
131
break;
132
case "0":
133
characters.push({
134
character,
135
frame: this.spriteMap.text.zero,
136
width: this.spriteMap.text.zero.width,
137
});
138
break;
139
}
140
}
141
142
const canvasWidth = characters.reduce(
143
(acc, { width }) => acc + width + this.characterPadding,
144
-this.characterPadding
145
);
146
this.textCanvas.width = canvasWidth;
147
// Add + 1 to account for the comma character dipping below the score
148
this.textCanvas.height = this.spriteMap.text.one.height + 1;
149
this.textContext.clearRect(
150
0,
151
0,
152
this.textCanvas.width,
153
this.textCanvas.height
154
);
155
156
let x = 0;
157
for (const character of characters) {
158
this.textContext.drawImage(
159
this.spriteSheet,
160
character.frame.sourceX,
161
character.frame.sourceY,
162
character.frame.width,
163
character.frame.height,
164
x,
165
0,
166
character.frame.width,
167
character.frame.height
168
);
169
170
x += character.width + this.characterPadding;
171
}
172
}
173
}

Let's break down some of the new properties that set the stage for our methods.

  • previousScore is the score since we last ran update(). We'll use this to check if the score has changed and redraw the score if it has. We don't need to waste time regenerating and redrawing the score if it hasn't changed.
  • scorePosition is the position of the score on the screen. Centered in x and 24 pixels down from the top.
  • textCanvas is our in memory canvas and we create it using the common document.createElement method.
  • textCanvas.height will never change because all our characters are the same height. It's the widht we need to change dynamically.
  • textContext is obtained from textCanvas. Recall we need a context object to actually draw to a canvas.
  • We call convertNumberToImage in the constructor for the first time to make sure we render a score of 0 to start.

Dynamic Score Construction

Let's focus on convertNumberToImage(score: number) method and it's associated logic.

First we convert the numberic score to a string using .toString(). This is done because we can iterate over a string easily. characters is our array of text characters associate with some sprite data.

As we iterate over each character in the string, we push a new object containing:

  • The character (digit) itself.
    • This won't be used again, but if you need to debug any of this code it's alot easier to know character this data is associated with.
  • The associated sprite data for that character, from the sprite map.
  • The width of the sprite data for that character. We'll need this to help resize the textCanvas.

After we've iterated over the string, we calculate the width of the canvas. We use reduce to sum the width of each character and the padding between each character and get a final width. The padding comes from characterPadding class property, which we set to 1. Something to note is we start the accumulator at -1 to account for the extra padding that will be added to the final character. This will negate that.

Lastly, we iterate our new characters array and draw each character while applying the appropriate padding.

Rendering the Score

Looking at the draw() method, we see a few noteworthy things:

  • If the previousScore is different than the score, we call convertNumberToImage(score: number) to regenerate the score.
  • We draw the textCanvas to the context - which belongs to the game canvas, at the scorePosition position.
  • We floor scorePosition.x to account for the possiblity that the combination of character widths and padding would produce a fractional pixel position.

Tying it Together

Lets make the appropriate changes to main.ts to tie everything together.

1
import spriteSheetUrl from "#/assets/image/spritesheet.png";
2
import { BoxCollider } from "#/components/box-collider";
3
import { CircleCollider } from "#/components/circle-collider";
4
import { SpriteAnimation } from "#/components/sprite-animation";
5
import { SpriteAnimationDetails } from "#/components/sprite-animation-details";
6
import { SpriteData } from "#/components/sprite-data";
7
import { Vector2d } from "#/components/vector2d";
8
import { config } from "#/config";
9
import { Bird } from "#/entities/bird";
10
import { Ground } from "#/entities/ground";
11
import { PipeManager } from "#/entities/pipe-manager";
+
import { ScoreManager } from "#/entities/score-manager";
13
import { Game, GameState } from "#/game";
14
import { loadImage } from "#/lib/asset-loader";
15
import { circleRectangleIntersects } from "#/lib/collision";
16
import { spriteMap } from "#/sprite-map";
17
18
// ...
19
20
canvas.addEventListener("click", () => {
21
switch (game.state) {
22
case GameState.Title: {
23
game.state = GameState.Playing;
24
bird.flap();
25
pipeManager.start();
26
27
break;
28
}
29
30
case GameState.Playing: {
31
bird.flap();
32
33
break;
34
}
35
36
case GameState.GameOver: {
37
game.reset();
38
bird.reset();
39
ground.start();
40
pipeManager.reset();
+
scoreManager.reset();
42
43
break;
44
}
45
}
46
});
47
48
// ...
49
+
const scoreManager = new ScoreManager({
+
config,
+
spriteMap,
+
spriteSheet,
+
});
55
56
let last = performance.now();
57
58
/**
59
* The game loop.
60
*/
61
const frame = (hrt: DOMHighResTimeStamp) => {
62
// ...
63
64
bird.draw(context);
65
pipeManager.draw(context);
66
ground.draw(context);
+
scoreManager.draw(context);
68
69
// ...
70
};

There you have it! We now have score keeping and what feels like a full game cycle, start to finish.